Refactor: decompose Parse::parse() into ParseContext + per-state handlers - #71
Merged
Conversation
Replace the ~24-key $emailAddress accumulator array threaded through parse() and its validation helpers with a typed ParseContext object. Property names mirror the former array keys so the change is a pure mechanical conversion with no behaviour change. A fresh ParseContext is created per parse() call and reset per address via resetAddress(); it is never stored on the Parse instance, preserving reentrancy across a localPartNormalizer callback. Type the addAddress() parameters (array/ParseContext/int) and drop the always-true isset() guard on the non-nullable domain property; refresh the PHPStan and Psalm baselines to drop the now-obsolete array-shape entries.
Decompose the ~772-line parse() state machine into a thin switch that dispatches to one handler per parser state, plus sub-handlers for the heavy STATE_ADDRESS branches (CFWS, '@', '.', atext, non-atext). parse() is now ~190 lines. Loop control (state/subState/commentNestLevel) and the hoisted input and config move onto ParseContext so the handlers read them without long parameter lists; behaviour, error codes and output are unchanged. Refresh the Psalm baseline for the state-machine narrowing false-positives that shift when the discriminant becomes a context property (PHPStan level 8 handles the mutation across calls and stays clean).
Fold the atext and period handling back inline into handleStateAddress so the dominant STATE_ADDRESS path makes a single method call per character instead of two. The larger, less-frequent branches (CFWS whitespace, '@', non-atext) stay in their own helpers. Behaviour is unchanged (full suite still green). Under opcache+JIT the batch-parsing benchmarks now run at or below the pre-refactor baseline.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #71 +/- ##
============================================
- Coverage 96.01% 95.75% -0.26%
- Complexity 434 446 +12
============================================
Files 6 7 +1
Lines 1078 1108 +30
============================================
+ Hits 1035 1061 +26
- Misses 43 47 +4
🚀 New features to boost your workflow:
|
Post-review cleanup for the parse() decomposition.
- Rename the parse-state local (and every handler parameter) from
$emailAddress to $ctx: it holds a ParseContext, not an address, and
the old name shadowed the concept of the address being built. Pure
mechanical rename, no behavior change.
- Make ParseContext::resetAddress(int $state, int $subState) the single
source of truth for per-address reset. It now also sets state/subState
and zeroes commentNestLevel, which nothing reset before: an
unterminated comment could leak its nesting level into the next address
in a batch, self-healing only because '(' reassigns it to 1. Both the
initial setup and the per-address reset in parse() now go through it.
Roadmap: mark the parse() readability refactor delivered and record the
remaining non-blocking follow-ups (snake_case fields, structural split of
the context's three concerns, chars/len duplication, handleStateAddress).
108 tests / 7199 assertions, PHPStan and CS clean.
Document the parser internals introduced by the parse() decomposition: the character-by-character dispatch loop, the 12 states and their 7 handlers, the ParseContext object and how a fresh instance per call keeps parse() reentrant, and the single-source-of-truth per-address reset. State machine and dispatch loop are drawn as mermaid so they render inline on GitHub. Complements DESIGN.md (RFC semantics) with the implementation shape; linked from the README docs line.
…line local Follow-up to the parse() decomposition review (PR #71). - Mark validateLocalPart() @internal. It is protected on a non-final class, so its array -> ParseContext signature change is technically a subclass break; but it takes the parser's internal accumulator and was never a supported extension point (validation is customized via ParseOptions). Documented as such and slated to go private in v4.0 (roadmap). - Give ParseContext a constructor requiring the initial state/subState, which runs resetAddress(). This makes an un-initialized context unrepresentable, replacing the "construct then remember to reset" pattern and the misleading zero-value field defaults (subState 0 = STATE_TRIM, not the required STATE_START). - Inline the single-use $separators local straight onto the context. 108 tests / 7172 assertions, PHPStan and CS clean.
…mpose # Conflicts: # README.md
The file had drifted into a jumbled mix of shipped and planned work with
duplicated and misfiled sections. Reorganized without dropping substance:
- Split into three clear parts: Released (v3.1-v3.8 + deprecations + docs),
Quality & infrastructure (continuous), and Planned (v4.0 + backlog).
- Removed the duplicate v4.0 list (the Deprecation Timeline had its own
"v4.0 - planned" subsection overlapping the real v4.0 section); merged and
deduped the API-cleanup items into a single v4.0 section.
- Refiled the shipped 3.8.0 homoglyph feature out of "v4.0 - Breaking" into
Released; its target-list follow-up stays under Planned.
- Compressed the now-delivered parse() decomposition to a one-line record
linking ARCHITECTURE.md, with the four review follow-ups moved to Backlog.
- Dropped the stale proposal text ("currently 99 tests", the pre-refactor
complexity pitch) and the trailing stray footnote.
The decomposition changed where Psalm narrows $ctx->state, so 15 baseline suppressions no longer match any code and CI (findUnusedBaselineEntry) fails them: 1 InvalidCast, 1 ParadoxicalCondition, 1 RedundantCondition, and 12 TypeDoesNotContainType. Regenerated with --update-baseline (69 -> 54 entries); no new suppressions added. Psalm, PHPStan L8, CS, and 108 tests all green.
Review-driven follow-ups on the parse() decomposition. - Add a reentrancy regression test: a localPartNormalizer that re-enters the same parser mid-parse. Locks in the property the whole ParseContext design exists for — a fresh per-call context, never on the instance — so a future change that stored parse state on $this would fail here. - Mark ParseContext @internal (its ~24-field shape is not a stable API) and fix an orphaned docblock: resetAddress()'s description had been stranded above __construct() when the constructor was added. - CHANGELOG [Unreleased]: record the internal decomposition and the validateLocalPart() array -> ParseContext / @internal change. - ROADMAP: expand the refactor follow-ups with the SOTA items surfaced in review — readonly snapshot/config fields, a ParserState backed enum (benchmark-gated), and decomposing addAddress(). 109 tests / 7177 assertions, PHPStan L8, Psalm, CS all green.
Makes the parse() decomposition fully backward compatible: no public or protected signature changes vs the previous release. validateLocalPart() reads only local_part_parsed and local_part_quoted from the accumulator, so instead of changing its signature to ParseContext it keeps its original array signature and is dispatched through a small legacy-array bridge at the one call site. It stays a live (virtual) extension point — a subclass override is still invoked — but is now @deprecated and removed in 4.0, where local-part validation folds into a private ParseContext-based method. The supported way to customize validation remains ParseOptions. - Added a BC regression test: a Parse subclass overriding validateLocalPart() still changes the outcome (rejects a 'blocked' local part). - ParseContext stays @internal. - One @psalm-suppress DeprecatedMethod on the intentional internal BC hook. 110 tests / 7193 assertions, PHPStan L8, Psalm, CS all green.
The v4.0 removal was already listed under Planned; add the matching entry to the Deprecations record so "what's deprecated / when removed" is complete.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Decomposes the ~772-line
Parse::parse()state machine into a thin dispatch loop over per-state handler methods, backed by a typedParseContextobject. Pure behavior-preserving refactor — no parsing logic, conditions, ordering, error codes, or output shape changed; the 108-case suite is untouched and output is byte-identical. Fully backward compatible: no public or protected method signature changed vs the previous release.Before → after
flowchart LR subgraph B["Before: one method"] P1["parse()<br/>772 lines<br/>one switch, 12 states<br/>~30 loose loop-locals<br/>untyped $emailAddress array"] end subgraph A["After: dispatcher + handlers + state object"] P2["parse()<br/>185 lines<br/>setup + dispatch loop"] H["7 state handlers<br/>+ 4 address sub-helpers"] C["ParseContext<br/>203 lines<br/>~24 typed fields"] P2 --> H P2 -. mutates .-> C H -. mutates .-> C end P1 ==>|"decompose"| P2parse()bodyswitch$emailAddressarrayParseContext— 203 lines, ~24 typed fieldsParse.php$emailAddress['key'](array)$ctx->key(typed property)LOC accounting (read past GitHub's diff)
GitHub's line-diff counts relocated and renamed lines as churn, so it reads like a rewrite. It isn't. Scoped to
src/:Parse.php+55 (1613 → 1668), newParseContext.php+203$emailAddress['x']→$ctx->x$emailAddress→$ctxSo the honest footprint is +258 net lines and two find-and-replace-shaped transforms — not the ~1.9k-line churn GitHub advertises. The counts are lenses, not addends (the 418/440 are occurrence counts within the 37% of edited lines).
What changed
parse(): 772 → 185 lines — setup + a thinswitch ($ctx->state)dispatching one handler per state, plus post-loop finalization.src/ParseContext.php— a per-parse object holding the ~24 accumulator fields + loop control + hoisted input/config as typed properties. Created fresh perparse()call and never stored on theParseinstance, so reentrancy is preserved (alocalPartNormalizercallback can re-enterparse()). Its constructor requires the initial state/sub-state, so an un-initialized context is unrepresentable.handleStateTrim,handleStateAddress,handleStateQuote,handleStateComment, …) —Parse.phpmethod count 19 → 28; the hot atext/period branches are inlined for performance.Independent verification (not just the author's report)
SEED=1/42/1337/99999; PHPStan level 8, Psalm, and CS all clean. Reentrancy confirmed with alocalPartNormalizerthat re-entersparse()— returns the correct result, no context corruption.master), Xdebug off. Refactored is faster — 1.88s vs 3.18s for 125k parses. (The sandbox's default config has Xdebug on + opcache-CLI off, which penalizes method calls; that instrumentation artifact accounts for any interpreted slowdown seen without it.)resetAddress()now zeroingcommentNestLevelper address) is inert: an address can never reachSTATE_END_ADDRESSinside a comment, so the value is always already 0 at address end.Design doc
ARCHITECTURE.mddocuments the resulting parser internals — the dispatch loop, the 12 states → 7 handlers, theParseContextanatomy and reentrancy rationale, and the per-address reset. The dispatch loop and state machine are mermaid diagrams and render inline on GitHub. It's the implementation companion toDESIGN.md(RFC semantics); linked from the README docs line.Post-review updates
$emailAddress→$ctx(481 sites) — it holds aParseContext, not an address; the plural results array$emailAddressesis untouched.resetAddress(int $state, int $subState)as the single source of truth. It now also clearsstate/subState/commentNestLevel— the last had no explicit reset before and only self-healed because a leading(reassigns it to 1, a latent leak for any future per-address field.validateLocalPart()kept fully backward compatible. Rather than change its signature (array→ParseContext), it retains its originalarraysignature and the parser dispatches through it via a small legacy-array bridge — so it stays a live extension point and existing subclass overrides still fire (regression-tested intestDeprecatedValidateLocalPartOverrideStillTakesEffect). It's now@deprecated(removed in 4.0, where local-part validation folds into a privateParseContext-based method);ParseOptionsis the supported way to customize validation.ParseContextis@internal.testParserIsReentrantAcrossLocalPartNormalizer) — a normalizer that re-enters the same parser mid-parse; locks in the fresh-context-per-call invariant.ROADMAP.md, not blocking this merge.